/* NOTICE
    OpenJaWS - Open Java Weather Station
    
    Copyright (C) 2008 Grant Gardner <grant@lastweekend.com.au>
    
    OpenJaWS is free software: you can redistribute it and/or modify
    it under the terms of the GNU General Public License as published by
    the Free Software Foundation, either version 3 of the License, or
    (at your option) any later version.

    This program is distributed in the hope that it will be useful,
    but WITHOUT ANY WARRANTY; without even the implied warranty of
    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    GNU General Public License for more details.

    You should have received a copy of the GNU General Public License
    along with this program.  If not, see <http://www.gnu.org/licenses/>.
    
NOTICE */

package au.com.lastweekend.ws2300.proxy;

import java.io.BufferedOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.OutputStream;
import java.util.ArrayList;
import java.util.List;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import au.com.lastweekend.openjaws.util.LineGobbler;
import au.com.lastweekend.openjaws.util.StringUtil;
import au.com.lastweekend.ws2300.Device;
import au.com.lastweekend.ws2300.RawDevice;
import au.com.lastweekend.ws2300.SerialDevice;
import au.com.lastweekend.ws2300.WS2300;
import au.com.lastweekend.ws2300.WS2300Simulator;

/**
 * Implements the device side of the WS2300 serial protocol.
 * <p>
 * Can be backed by any DeviceInterface, including a real WS2300 via SerialDevice.
 * <p>
 * Useful with a serial interceptor such as "interceptty"
 * 
 * @author ggardner
 * 
 */
public class WS2300Proxy extends Thread {

    public class LoggingOutputStream extends BufferedOutputStream {

        public LoggingOutputStream(OutputStream out) {

            super(out);

        }

        public String getBuffer() {

            return StringUtil.toHexBytes(buf, 0, count);
        }

    }

    public enum State {
        ADDRESS, ERROR, ADDRESS_COMPLETE, RECEIVE_DATA
    }

    private static final Logger LOG = LoggerFactory.getLogger(WS2300Proxy.class);

    private InputStream inputStream;
    private LoggingOutputStream outputStream;
    private boolean stopped = false;
    private State state = State.ADDRESS;
    private int address = 0;
    private int addressCount = 0;
    private Device backDevice;

    public WS2300Proxy(InputStream in, OutputStream out, Device backDevice) {

        inputStream = in;
        outputStream = new LoggingOutputStream(out);
        if (backDevice == null) {
            backDevice = new MemoryDevice();
        }

        this.backDevice = backDevice;
    }

    @Override
    public void run() {

        while (!stopped) {
            try {

                int command = inputStream.read();
                if (command < 0) {
                    LOG.debug("Input stream closed. Stopping");
                    stopped = true;
                    continue;
                }
                int result = process(command);
                outputStream.write(result);
                LOG.debug(String.format("Read: %02x", command) + ", Writing:" + outputStream.getBuffer());
                outputStream.flush();

            } catch (IOException e) {
                state = State.ERROR;
                LOG.error("Stopping due to exception", e);
                stopped = true;
            }
        }
    }

    private int process(int command) throws IOException {

        // cc .. .. 10, cc = command switch
        switch (command & RawDevice.COMMAND_READ) {

            case (RawDevice.COMMAND_READ) :
                // 11 nn nn 10, n = numBytesToRead
                return read((command >> 2) & 0xF);
            case (RawDevice.COMMAND_ADDRESS) :
                // 10 aa aa 10, a = addressNibble
                return address((command >> 2) & 0xF);

            case (RawDevice.COMMAND_WRITE) :
                // 01 vv vv 10, v = nibbleValue
                return writeNibble((command >> 2) & 0xF);

            case (RawDevice.COMMAND_ALL) :
                // 00 cc .. 10, cc = command switch
                switch (command & RawDevice.COMMAND_UNSET_BIT) {
                    case (RawDevice.COMMAND_UNSET_BIT) :
                        // 00 11 bb 10, b = bitToUnset
                        return setBit((command >> 2) & 0x3, false);

                    case (RawDevice.COMMAND_SET_BIT) :
                        // 00 01 bb 10, b = bitToSet
                        return setBit((command >> 2) & 0x3, true);

                    case (RawDevice.COMMAND_ALL) :
                        // 00 00 cc 10
                        switch (command & 0x0E) {
                            case (RawDevice.COMMAND_RESET) :
                                // 00 00 01 10, reset.
                                return reset();
                            default :
                        }
                    default :
                }
            default :
                LOG.error("Invalid command " + Integer.toBinaryString(command));
                return error();

        }
    }

    private int read(int numBytes) throws IOException {

        if (state != State.ADDRESS_COMPLETE) {
            return error();
        }

        outputStream.write(0x30 + numBytes);

        int checksum = 0;

        byte[] readNibbles = backDevice.read(address, numBytes);

        byte[] compressedNibbles = new byte[numBytes];

        for (int i = 0; i < numBytes; i++) {
            compressedNibbles[i] = (byte) ((readNibbles[2 * i + 1] << 4) + readNibbles[2 * i]);
            checksum += compressedNibbles[i];
        }
        outputStream.write(compressedNibbles);

        checksum = checksum & 0xFF;

        state = State.ADDRESS;
        return checksum;
    }

    private int setBit(int bitAddress, boolean setBit) throws IOException {

        if (state != State.ADDRESS_COMPLETE) {
            return error();
        }

        backDevice.writeBit(address, bitAddress, setBit);

        int bitCheck = (setBit ? RawDevice.ACK_SET_BIT : RawDevice.ACK_UNSET_BIT) + bitAddress;

        // ready for the next address
        state = State.ADDRESS;
        return bitCheck;

    }

    private int writeNibble(int value) throws IOException {

        if (state == State.ADDRESS_COMPLETE) {
            state = State.RECEIVE_DATA;
        }

        if (state != State.RECEIVE_DATA) {
            LOG.warn("Bad state " + state + " while trying to write nibble");
            return error();
        }

        backDevice.write(address, new byte[]{(byte) value});

        address++;

        return RawDevice.ACK_WRITE + value;
    }

    private int address(int addressNibble) {

        if (state == State.RECEIVE_DATA) {
            state = State.ADDRESS;
        }

        if (state != State.ADDRESS || addressCount > 3) {
            return error();
        }

        if (addressCount == 0) {
            address = 0;
        }

        address = (address << 4) + addressNibble;
        int checksum = (addressCount << 4) + addressNibble;
        addressCount += 1;

        if (addressCount == 4) {
            state = State.ADDRESS_COMPLETE;
            addressCount = 0;
        }

        return checksum;

    }

    private int reset() {

        state = State.ADDRESS;
        addressCount = 0;
        return 0x02;

    }

    private int error() {

        state = State.ERROR;
        return 0xFF;
    }

    public static Process interceptty(final Logger baseLogger, String serialPort, String... proxyArgs) throws IOException {

        final Logger intercepttyLogger = LoggerFactory.getLogger(baseLogger.getName() + ".interceptty");

        String maven = System.getProperty("localRepository");
        String classPath;
        if (maven == null) {
            classPath = System.getProperty("java.class.path");
        } else {
            // Horrendous hack because we can't get the dependencies
            // from maven directly
            String baseDir = System.getProperty("basedir");
            classPath = baseDir + "/target/classes:" + baseDir + "/target/test-classes:" + baseDir
                    + "/../../modules/common/target/classes:" + maven + "/org/slf4j/slf4j-api/1.4.3/slf4j-api-1.4.3.jar:" + maven
                    + "/org/slf4j/slf4j-simple/1.4.3/slf4j-simple-1.4.3.jar";

        }

        ProcessBuilder builder = new ProcessBuilder();
        List<String> args = new ArrayList<String>();
        args.add("interceptty");
        if (!intercepttyLogger.isDebugEnabled()) {
            args.add("-q");
        }

        StringBuilder proxyCommand = new StringBuilder("!java -cp ");
        proxyCommand.append(classPath);
        proxyCommand.append(' ');

        boolean jvmArgs = true;
        for (String proxyArg : proxyArgs) {
            if (jvmArgs && !proxyArg.startsWith("-")) {
                proxyCommand.append(' ');
                proxyCommand.append(WS2300Proxy.class.getCanonicalName());
                jvmArgs = false;
            }
            proxyCommand.append(' ');
            proxyCommand.append(proxyArg);
        }

        if (jvmArgs) {
            throw new IOException("No args supplied to WS2300Proxy behind interceptty");
        }

        args.add(proxyCommand.toString());

        args.add(serialPort);
        builder.command(args);

        Process intercepttyProcess = builder.start();
        LineGobbler errorGobbler = new LineGobbler(intercepttyProcess.getErrorStream()) {

            @Override
            protected void gobble(String line) {

                // Logging of the interceptty has to go to stderr because stdin
                // is the tty
                intercepttyLogger.info(line);
            }

            @Override
            protected void handleReaderException(IOException e) {

                baseLogger.debug("Exception gobbling interceptty error output", e);
            }
        };
        new Thread(errorGobbler).start();

        if (intercepttyLogger.isDebugEnabled()) {
            LineGobbler outputGobbler = new LineGobbler(intercepttyProcess.getInputStream()) {

                @Override
                protected void gobble(String line) {

                    intercepttyLogger.debug(line);
                }

                @Override
                protected void handleReaderException(IOException e) {

                    baseLogger.debug("Exception gobbling interceptty output", e);
                }

            };
            new Thread(outputGobbler).start();
        }

        baseLogger.debug("Started: " + builder.command().toString());

        return intercepttyProcess;
    }

    /**
     * <pre>
     *  Usage: WS2300Proxy i|m|s args
     *  i backDevice [-javaArgs] [proxyArgs]- start WS2300Proxy behind interceptty
     *  m [dumpFile [timeWarp]] - start in-memory proxy
     *          optionally loaded from dumpFile
     *          optionally start Simulator with timeWarp real milliseconds to represent a second 
     *  s serialPort - start serial device proxy
     * </pre>
     * 
     * @param args
     * 
     * @throws IOException
     */
    public static void main(String[] args) throws IOException {

        if (args.length == 0) {
            usage();
            return;
        }

        if ("i".equals(args[0])) {
            // i backdevice proxyArgs
            String[] newArgs = new String[args.length - 2];
            System.arraycopy(args, 2, newArgs, 0, newArgs.length);
            final Process interceptty = interceptty(LOG, args[1], newArgs);
            Runtime.getRuntime().addShutdownHook(new Thread() {

                public void run() {

                    interceptty.destroy();
                    LOG.info("Destroyed interceptty");
                }
            });
            LOG.info("Started interceptty" + StringUtil.toString(args));
            return;
        }

        Device device;
        if ("m".equals(args[0])) {
            // m [loadFile]
            device = new MemoryDevice();
            if (args.length > 1) {
                InputStream dumpResourceStream = WS2300Proxy.class.getResourceAsStream(args[1]);
                if (dumpResourceStream == null) {
                    LOG.error("Unable to load " + args[1]);
                    usage();
                    return;
                }
                device.load(new InputStreamReader(dumpResourceStream));
            }
            if (args.length > 2) {
                WS2300Simulator simulator = new WS2300Simulator(new WS2300(device));
                simulator.setTimeWarp(Integer.parseInt(args[2]));
                simulator.start();
            }
        } else if ("s".equals(args[0])) {
            // s serialPort
            device = new SerialDevice(args[0]);
        } else {
            usage();
            return;
        }

        WS2300Proxy proxy = new WS2300Proxy(System.in, System.out, device);
        proxy.start();
        LOG.info("Started Proxy " + StringUtil.toString(args));
    }

    private static void usage() {

        StringBuilder sb = new StringBuilder("\n");
        sb.append("Usage: WS2300Proxy i|m|s args\n");
        sb.append("i backDevice [-javaArgs] [proxyArgs]- start WS2300Proxy behind interceptty\n");
        sb.append("m [dumpFile] - start in-memory proxy, optionally loaded from dumpFile\n");
        sb.append("s serialPort - start serial device proxy\n");
        LOG.error(sb.toString());
        System.exit(1);
    }
}
